Constructing Morphological Networks from Overture Maps#
In this notebook, we’ll walk through the following workflow:
Data Preparation: Loading and processing street segments and building data from Overture Maps
Space Division: Creating morphological tessellations to represent private spaces
Network Creation: Generating different types of morphological networks:
Private-to-private connections (between adjacent private spaces)
Public-to-public connections (between street segments)
Private-to-public connections (between private spaces and streets)
Graph Conversion: Converting spatial networks to graph representations using PyTorch Geometric
Visualization: Exploring different ways to visualize the morphological networks
These graph representations enable various analyses of urban form, including connectivity studies, accessibility measures, and graph-based machine learning for urban analytics.
1. Setup and Environment#
[ ]:
import numpy as np
from shapely import Point
import geopandas as gpd
import matplotlib.pyplot as plt
import contextily as cx
import city2graph
import folium
# Configure matplotlib for better visualizations
plt.rcParams['figure.figsize'] = (12, 10)
plt.rcParams['figure.dpi'] = 100
plt.style.use('ggplot')
2. Data Loading and Preparation#
We’ll work with data from Liverpool, UK, including building footprints and road network segments. These data are stored as GeoJSON files. area accepts either bbox or Polygon in WGS84.
[ ]:
bbox = [-3.090173,53.355487,-2.917138,53.465587] # Liverpool city centre
city2graph.load_overture_data(area=bbox,
types=["segment", "building", "connector"],
output_dir=".",
prefix="liverpool_",
save_to_file=True,
return_data=False)
[5]:
# Load GeoJSON files
buildings_gdf = gpd.read_file("liverpool_building.geojson")
segments_gdf = gpd.read_file("liverpool_segment.geojson")
connectors_gdf = gpd.read_file("liverpool_connector.geojson")
# Convert to British National Grid (EPSG:27700)
buildings_gdf = buildings_gdf.to_crs(epsg=27700)
segments_gdf = segments_gdf.to_crs(epsg=27700)
connectors_gdf = connectors_gdf.to_crs(epsg=27700)
print(f"Loaded {len(buildings_gdf)} buildings, {len(segments_gdf)} segments, and {len(connectors_gdf)} connectors")
Loaded 132972 buildings, 37474 segments, and 47367 connectors
3. Street Network Processing#
city2graph offers functions that can process and clean up the original segments from Overture Maps. Eventually, barrier_geometry stores the ones not considered as tunnels and/or bridges.
[6]:
# Filter road segments and process them to handle tunnels
segments_gdf = segments_gdf[segments_gdf["subtype"] == "road"].copy()
# Identify tunnels and set their geometry to None
segments_gdf["barrier_mask"] = segments_gdf["level_rules"].apply(city2graph.identify_barrier_mask)
# Split segments by connectors
segments_gdf = city2graph.split_segments_by_connectors(segments_gdf, connectors_gdf)
# Filter road segments and process them to handle tunnels
segments_gdf = city2graph.adjust_segment_connectors(segments_gdf, threshold=1.0)
# Calculate the length of each segment
segments_gdf["length"] = segments_gdf.geometry.length
# Create barrier_geometry column by processing each segment (excluding tunnels)
segments_gdf["barrier_geometry"] = city2graph.get_barrier_geometry(segments_gdf)
4. Create Morphological Network#
Based on the preprocessed segments and buildings, a morphological network is created by the following process:

Morphological network is a heterogenous graph where all the spaces in cities are holistically represented as nodes.
In this representation, the topological connections between public and private spaces can be captured. Unlike street network and contiguity matrix of built environment, morphological network can accomodate any of the charactors for urban forms and functions, such as POIs, land-use, street types, and etc. It will be a foundation for spatially-explicit GeoAI by graph representation learning, keeping the characters as node attributes.
There are three types of connections as edges: public-to-public, private-to-private, and private-to-public. Public-to-public connections are dual representation of street networks. Private-to-private shows the contiguities between enclosed tessellations as plot systems. Private-to-public can identify the adjacency between public and private spaces.
city2graph.morphological_network can process the creation of morphological network as a dictionary of gpd.GeoDataFrame consisting of ['tessellations', 'segments', 'buildings', 'private_to_private', 'public_to_public', 'private_to_public']. Post-processing, such as mapping POIs to private spaces, can be done accordingly.
[9]:
center_point = gpd.GeoSeries([Point((-2.9879004, 53.4062724))], crs='EPSG:4326').to_crs(epsg=27700)
morphological_network = city2graph.morphological_network(
buildings_gdf=buildings_gdf,
segments_gdf=segments_gdf,
center_point=center_point,
distance=500,
public_geom_col='barrier_geometry',
contiguity="queen"
)
/opt/anaconda3/envs/city2graph_env/lib/python3.12/site-packages/libpysal/weights/contiguity.py:347: UserWarning: The weights matrix is not fully connected:
There are 3 disconnected components.
There are 2 islands with ids: 2, 4.
W.__init__(self, neighbors, ids=ids, **kw)
[11]:
morphological_network.keys()
[11]:
dict_keys(['tessellations', 'segments', 'buildings', 'private_to_private', 'public_to_public', 'private_to_public'])
[19]:
# Set up the figure with a nice size and background
fig, ax = plt.subplots(figsize=(14, 12), facecolor='#f9f9f9')
# Plot central point
ax.scatter(center_point.x, center_point.y, color='black', marker='*', s=200, zorder=5, label='Center Point')
# Plot background elements with improved styling
morphological_network["tessellations"].plot(ax=ax, color='#ADD8E6', edgecolor='#87CEEB', linewidth=0.2, alpha=0.2)
morphological_network["buildings"].plot(ax=ax, color='#e0e0e0', edgecolor='#c0c0c0', linewidth=0.3, alpha=0.7)
morphological_network["segments"].plot(ax=ax, color='#404040', linewidth=0.7, alpha=0.6)
# Plot the three network types with distinctive styles
morphological_network["private_to_private"].plot(ax=ax, color='#B22222', linewidth=1.5, alpha=0.7)
morphological_network["public_to_public"].plot(ax=ax, color='#0000FF', linewidth=1.0, alpha=0.7)
morphological_network["private_to_public"].plot(ax=ax, color='#7B68EE', linewidth=1.0, alpha=0.7, linestyle='--')
# Add nodes: private nodes from tessellation centroids (red) and public nodes as midpoints of segments (blue)
private_nodes = morphological_network["tessellations"].centroid
ax.scatter(private_nodes.x, private_nodes.y, color='red', s=20, zorder=10, label='Private Spaces')
public_nodes = morphological_network["segments"].geometry.apply(lambda geom: geom.interpolate(0.5, normalized=True))
ax.scatter(public_nodes.x, public_nodes.y, color='blue', s=20, zorder=10, label='Public Spaces')
# Create a legend with clear labels
legend_elements = [
plt.Line2D([0], [0], color='black', marker='*', linestyle='None', markersize=10, label='Center Point'),
plt.Rectangle((0, 0), 1, 1, color='#e0e0e0', label='Buildings'),
plt.Line2D([0], [0], color='#404040', lw=1.5, label='Street Segments'),
plt.Rectangle((0, 0), 1, 1, color='#ADD8E6', alpha=0.3, label='Tessellation Cells'),
plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='red', markersize=8, linestyle='None', label='Private Nodes'),
plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='blue', markersize=8, linestyle='None', label='Public Nodes'),
plt.Line2D([0], [0], color='red', lw=1, label='Private-to-Private'),
plt.Line2D([0], [0], color='blue', lw=1, label='Public-to-Public'),
plt.Line2D([0], [0], color='#7B68EE', lw=1, linestyle='--', label='Private-to-Public'),
]
# Position the legend outside the plot
ax.legend(handles=legend_elements, loc='upper left', bbox_to_anchor=(1.01, 1),
frameon=True, facecolor='white', framealpha=0.9, fontsize=12)
# Add title and remove axes
ax.set_axis_off()
# Add basemap from Stamen Terrain below everything else
cx.add_basemap(ax, crs='EPSG:27700', source=cx.providers.CartoDB.Positron, alpha=1)
plt.tight_layout()
plt.show()
Interactive Visualization with Folium#
[ ]:
def add_gdf_to_map(gdf, map_obj, style_function, tooltip=None, layer_name='Layer'):
# Create a copy and drop any problematic columns (non-JSON serializable)
gdf_copy = gdf.copy()
for col in ['barrier_geometry', '_original_geometry']:
if col in gdf_copy.columns:
gdf_copy = gdf_copy.drop(columns=[col])
# Add the GeoJSON layer
folium.GeoJson(
gdf_copy.to_crs(epsg=4326),
name=layer_name,
style_function=style_function,
tooltip=tooltip
).add_to(map_obj)
def add_endpoints_from_lines(gdf, map_obj, color):
# For each linestring, mark its start and end with CircleMarkers
for geom in gdf.to_crs(epsg=4326).geometry:
coords = list(geom.coords)
for coord in (coords[0], coords[-1]):
folium.CircleMarker(
location=[coord[1], coord[0]],
radius=3,
color=color,
fill=True,
fill_color=color
).add_to(map_obj)
# Convert the study area center to EPSG:4326 once
center_point_4326 = center_point.to_crs(epsg=4326)
center_lat = center_point_4326.geometry.iloc[0].y
center_lon = center_point_4326.geometry.iloc[0].x
# Create the base map
m = folium.Map(
location=[center_lat, center_lon],
zoom_start=15,
tiles='https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attr='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
)
# Configuration for layer additions from morphological_network
layers_config = [
{
'data': morphological_network["buildings"],
'name': 'Buildings',
'style': lambda x: {'fillColor': 'black', 'color': None, 'weight': 0, 'fillOpacity': 0.1}
},
{
'data': morphological_network["segments"],
'name': 'Street Segments',
'style': lambda x: {'color': 'blue', 'weight': 2, 'opacity': 1},
'tooltip': folium.GeoJsonTooltip(
fields=['id', 'class', 'subtype', 'length'],
aliases=['ID:', 'Road Class:', 'Subtype:', 'Length (m):'],
localize=True,
sticky=False,
labels=True
)
},
{
'data': morphological_network["tessellations"],
'name': 'Tessellation',
'style': lambda x: {'fillColor': '#ff0000', 'color': '#ff0000', 'weight': 1, 'fillOpacity': 0},
'tooltip': folium.GeoJsonTooltip(
fields=['tess_id', 'enclosure_index'],
aliases=['Tessellation ID:', 'Enclosure Index:'],
localize=True,
sticky=False,
labels=True
)
},
{
'data': morphological_network["private_to_private"],
'name': 'Private-to-Private',
'style': lambda x: {'color': 'red', 'weight': 2, 'opacity': 1, 'dashArray': '5,5'},
'tooltip': folium.GeoJsonTooltip(
fields=['from_private_id', 'to_private_id'],
aliases=['From:', 'To:'],
localize=True,
sticky=False,
labels=True
)
},
{
'data': morphological_network["public_to_public"],
'name': 'Public-to-Public',
'style': lambda x: {'color': '#0000FF', 'weight': 2, 'opacity': 1, 'dashArray': '5,5'},
'tooltip': folium.GeoJsonTooltip(
fields=['from_public_id', 'to_public_id'],
aliases=['From:', 'To:'],
localize=True,
sticky=False,
labels=True
)
},
{
'data': morphological_network["private_to_public"],
'name': 'Private-to-Public',
'style': lambda x: {'color': '#7B68EE', 'weight': 2, 'opacity': 1, 'dashArray': '5,5'},
'tooltip': folium.GeoJsonTooltip(
fields=['private_id', 'public_id'],
aliases=['Private Node:', 'Public Node:'],
localize=True,
sticky=False,
labels=True
)
}
]
for layer in layers_config:
add_gdf_to_map(layer['data'], m, layer['style'], tooltip=layer.get('tooltip'), layer_name=layer['name'])
# Add endpoints for connection layers
add_endpoints_from_lines(morphological_network["public_to_public"], m, color='#0000FF')
add_endpoints_from_lines(morphological_network["private_to_private"], m, color='red')
# Add layer control and display the map
folium.LayerControl().add_to(m)
m